250618 动态地形与粒子水?小鳄鱼爱洗澡的实现方法!

怎么通篇都是后处理

预览

预览

500粒子90帧左右,600往上帧率骤降,700左右为游戏赋予24帧电影感。

水流

思路

粒子水是一种很经典的流体模拟方式。这个“粒子”指的不一定是ParticleSystem,也可以是一堆普普通通的挂载了CircleCollider2D与Rigidbody2D的圆。使用两个摄像机:由副摄像机渲染这些粒子,将渲染结果放到一张RenderTexture(简称RT)上;主摄像机不渲染这些粒子,而是渲染这张经过后处理的RT。

油管上这两个视频讲得很清楚:

https://www.youtube.com/watch?v=_8v4DRhHu2g

https://www.youtube.com/watch?v=3r6o8CfRlA4

这里稍作整理。

实现

至于怎么让这个粒子出现在场景中就省略了

效果如下:

效果

地形

思路

注意到SpriteRenderer与PolygonCollider2D先后挂载到同一物体上时,碰撞箱范围会随图片透明度改变。这个碰撞箱具体是什么范围,可以在PolygonCollider2D的Points属性里看到。Sprite其实可以获取这些顶点信息(详见Sprite.GetPhysicsShape),从而动态调整Points属性。

参考这个视频,一开始的思路是,根据鼠标位置改变图片的透明度,从而影响SpriteRenderer同物体上的PolygonCollider2D。尝试后发现达到了惊人的个位数帧率,并且性能瓶颈在动态改变图片像素上,无法优化,遗憾离场。

还有一种思路:将物理与图像拆开。物理上,调用NetTopologySuite(简称NTS)计算顶点信息,使用EdgeCollider2D以便调整;图像上,沿用粒子水的后处理思路,记录鼠标轨迹,影响背景图片的透明度。

实现

物理

NuGet

这图其实还是草率了点

示例

// Pointer.cs
using NetTopologySuite.Geometries;
using System.Collections.Generic;
using UnityEngine;

public class Pointer : MonoBehaviour
{
    [SerializeField] private Transform terrain; // EdgeCollider2D的父物体
    [SerializeField] private float radius = 1; // 指定挖掘范围
    
    private Vector2 GetCursorPos() => Camera.main.ScreenToWorldPoint(Input.mousePosition);
    // 古法画圆
    private Polygon CreateSphere(Vector2 center) =>
        new(new LinearRing(new Coordinate[]
        {
            new (center.x + radius, center.y),
            new (center.x + radius * .707, center.y - radius * .707),
            new (center.x, center.y - radius),
            new (center.x - radius * .707, center.y - radius * .707),
            new (center.x - radius, center.y),
            new (center.x - radius * .707, center.y + radius * .707),
            new (center.x, center.y + radius),
            new (center.x + radius * .707, center.y + radius * .707),
            new (center.x + radius, center.y),
        }));
    // 根据一组顶点设置一个EdgeCollider2D
    private void SetEdge(EdgeCollider2D edge, Geometry geometry)
    {
        List<Vector2> list = new();
        foreach (var point in geometry.Coordinates) list.Add(new((float)point.X, (float)point.Y));
        edge.SetPoints(list);
        edge.transform.parent = terrain;
    }

    private void Update()
    {
        if (!Input.GetMouseButton(0)) return;
        
        // 这里有一大堆有待优化的GC,但帧率大幅下降的时候,Profiler里面基本只能看到物理的开销
        transform.position = GetCursorPos(); // 跟随鼠标
        var pointer = CreateSphere(transform.position);
        var edges = terrain.GetComponentsInChildren<EdgeCollider2D>();

        for (int i = 0; i < edges.Length; i++)
        {
            // 将每一个EdgeCollider转化为NTS的几何信息
            var points = edges[i].points;
            var coordinates = new Coordinate[points.Length + 1];
            for (int j = 0; j < points.Length; j++)
                coordinates[j] = new(points[j].x, points[j].y);
            coordinates[points.Length] = new(points[0].x, points[0].y);
            var poly = new Polygon(new LinearRing(coordinates));
            // 逐碰撞体求待挖掘范围与鼠标范围的差集
            if (poly.IsValid && poly.Overlaps(pointer))
                switch (poly.Difference(pointer))
                {
                    // 鼠标范围把一个碰撞体分成了多块
                    case MultiPolygon multi:
                        bool first = true;
                        foreach (var geo in multi)
                            if (geo.IsValid)
                                if (first)
                                {
                                    // 第一块直接修改原碰撞体
                                    SetEdge(edges[i], geo);
                                    first = false;
                                }
                                // 之后的则添加新碰撞体
                                else SetEdge(new GameObject().AddComponent<EdgeCollider2D>(), geo);
                        break;
                    // 原碰撞体并没有被分成多块
                    case Polygon single: if (single.IsValid) SetEdge(edges[i], single); break;
                }
        }
    }
    // 预览挖掘范围
    private void OnDrawGizmos()
    {
        Gizmos.color = Color.blue;
        Gizmos.DrawWireSphere(transform.position, radius);
    }
}

图像

优化

粒子看着太粒子了

圆形的粒子太容易被看穿,一旦有粒子从一大片粒子中脱出,或者有即将脱出的迹象,这个圆形就会非常显眼。

按照策划同学的建议,把粒子的图片从1: 1的正圆,拉伸成了1.5: 1的椭圆。一开始我以为无济于事,但是试着动了一下,奇迹发生了,它变得像真正的河水一样奔涌起伏。

效果

河水有点太细小了

这个水流对于小鳄鱼爱洗澡已经够用了,但是对于河流却过于细小。

此外,部分粒子还可能被卡到本来不该出现的位置,出现穿帮,严重影响观感;而这很难通过物理修改,毕竟物理模拟已经全部交给Unity了。

还是按照策划同学的建议,试着将图片放大为原来的两倍,又不改变碰撞体大小。由于河床上层并非不透明,泥土之下难免暗流涌动。

好在我们之前记录过鼠标轨迹,只需要将记录鼠标轨迹的RT作为遮罩,加入到粒子水的后处理中。

河道可以顺利填满,被卡出去的粒子也无法显示,再也不用再担心穿帮问题了!

(预览见最上方)

剩下的问题


参考

https://www.youtube.com/watch?v=_8v4DRhHu2g

https://www.youtube.com/watch?v=3r6o8CfRlA4

https://www.bilibili.com/video/BV133vfePERy/?share_source=copy_web&vd_source=8228515ea2e08a31d762872edc880d88

https://github.com/NetTopologySuite/NetTopologySuite